[Kotlin] Coroutine 동작 원리(CPS, Dispatcher, 구조화된 동시성)

개요

코루틴(Coroutine)은 코틀린이 제공하는 경량 실행 단위다. 하나의 JVM(Java Virtual Machine) 스레드 위에서 수천, 수만 개의 코루틴이 동시에 진행될 수 있고, 비동기 코드를 동기 코드처럼 일자로 적을 수 있게 해준다. 자바의 스레드만 다루다가 코루틴을 처음 만나면 마치 새로운 종류의 스레드처럼 보이지만, 실제로는 컴파일러가 만들어낸 상태 머신과 런타임의 디스패처(Dispatcher)가 결합된 추상화에 가깝다.

자바의 일반 스레드는 OS 커널 스레드와 1대1로 매핑되고, 컨텍스트 스위칭은 커널 레벨에서 일어난다. 스레드 하나당 기본 스택 크기가 수백 KB 단위이고, 1만 개를 띄우면 그 자체로 수 GB 메모리를 차지한다. 네트워크 호출을 기다리는 동안 스레드를 점유하는 모델은 동시 접속 수가 늘어나면 스레드들은 대기 상태에 빠진다. 코루틴은 이 한계를 컴파일러 변환과 유저 레벨 스케줄링으로 풀어낸다.

키워드정의
suspend일시 중단 가능한 함수임을 표시하는 한정자. 컴파일러가 CPS 변환의 대상으로 인식한다.
Continuation중단된 코루틴의 재개점을 들고 있는 콜백 객체. 상태 머신의 다음 라벨을 호출한다.
CPS(Continuation Passing Style)함수의 마지막 인자로 Continuation을 받아 결과를 콜백으로 전달하는 호출 규약.
Dispatcher코루틴을 실제로 어떤 스레드에서 실행할지를 결정하는 컴포넌트.
CoroutineScope코루틴의 생명주기와 부모 자식 관계를 묶어주는 컨텍스트 컨테이너.
Job코루틴의 상태(Active, Cancelled, Completed)와 부모 자식 트리를 관리하는 핸들.
Channel코루틴 사이에서 값을 주고받는 핫(Hot) 스트림 큐.

비선점형 스케줄링과 경량성

자바 스레드는 OS가 선점(Preemptive)한다. 스케줄러가 타임 슬라이스를 다 쓴 스레드를 강제로 멈추고, 다음 스레드의 레지스터, 스택 포인터, 메모리 매핑 정보를 커널 영역에서 복원한다. 이 컨텍스트 스위칭은 비용이 크고, 동기화 락(Lock)과 결합되면 비용이 더 커진다.

코루틴은 비선점형(Cooperative)으로 동작한다. 코루틴은 스스로 양보 지점을 만나야만 중단된다. 양보 지점은 suspend 함수 호출이고, 이때 현재까지의 지역 변수, 다음에 실행할 라벨 번호를 Continuation 객체에 담아 힙에 보관한다. 그동안 점유하던 스레드는 그대로 다른 코루틴의 본문을 실행한다. 입출력이 끝나면 디스패처가 유휴 스레드를 골라 Continuation의 resume을 호출하고, 라벨 분기를 따라 중단점 바로 다음 줄부터 실행이 이어진다. 스택 프레임을 OS가 통째로 갈아끼우는 대신 컴파일러가 만든 작은 자바 객체 하나만 교체하면 끝나기 때문에, 스레드 1000개가 수백 MB를 요구하는 것과 달리 코루틴 1000개는 객체 1000개분의 힙 비용만 든다.

구분일반 스레드코루틴버추얼 스레드
매핑커널 스레드 1대1다대다, Dispatcher가 분배JVM 캐리어 스레드 다대다
컨텍스트 스위칭커널 레벨컴파일러 생성 객체 교체JVM의 mount, unmount
양보 시점선점suspend 호출 시점블로킹 입출력 시점
스택 모델OS 스택힙의 Continuation힙의 스레드 스택 복제
언어 영향없음suspend라는 새 함수 종류 도입표준 API 그대로

CPS 변환과 상태 머신

다음과 같은 suspend 함수가 있다고 하자.

suspend fun fetchData(userId: String): Data {
    val token = authService.getToken(userId)
    val data = dataService.fetch(token)
    return Data(data)
}

코틀린 컴파일러는 이 함수를 다음과 비슷한 형태로 변환한다.

fun fetchData(userId: String, cont: Continuation<Data>): Any? {
    val sm = cont as? FetchDataSM ?: FetchDataSM(cont)
    when (sm.label) {
        0 -> {
            sm.userId = userId
            sm.label = 1
            val r = authService.getToken(userId, sm)
            if (r == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
            sm.tmp = r
        }
        1 -> {
            sm.token = sm.tmp as String
            sm.label = 2
            val r = dataService.fetch(sm.token, sm)
            if (r == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
            sm.tmp = r
        }
        2 -> return Data(sm.tmp as RawData)
    }
    error("unreachable")
}

함수 시그니처 끝에 Continuation 인자가 추가되고, 함수 본문이 when(label) 블록 안의 분기들로 쪼개지며, 지역 변수가 상태 머신(State Machine) 객체의 필드로 승격된다. suspend 호출이 등장할 때마다 새 라벨이 부여되고, 함수가 중간에 끝나도 변수 값이 상태 머신 객체 안에 남아 다음 라벨에서 이어서 쓸 수 있다.

중단된 경우 함수는 COROUTINE_SUSPENDED를 반환하고, 디스패처가 입출력 완료 콜백을 받아 Continuation.resumeWith를 호출해야 다음 라벨로 진입한다.

모든 상태가 자바 힙의 상태 머신 객체에 들어 있고, Continuation은 그 객체에 대한 콜백 참조에 가깝다. 다음 시퀀스는 suspend 함수가 한 번 양보하고 다시 깨어나는 흐름이다.

sequenceDiagram
    autonumber
    participant Caller as 호출 코루틴
    participant Func as fetchData 상태머신
    participant IO as authService(IO)
    participant Disp as Dispatcher

    Caller->>Func: invoke(userId, cont) label=0
    Func->>IO: getToken(userId, sm)
    IO-->>Func: COROUTINE_SUSPENDED
    Func-->>Caller: COROUTINE_SUSPENDED
    Note over Caller,Disp: 스레드는 즉시 풀로 반환
    IO-->>Disp: 토큰 응답 도착, resumeWith 콜백 전달
    Disp->>Func: resumeWith(token), label=1 진입
    Func->>Func: 다음 라벨 실행

Dispatcher와 CoroutineContext

상태 머신과 Continuation만 있어도 중단과 재개가 되지만, 그 코드를 실제로 어떤 스레드 위에서 굴릴지는 별도 결정이 필요하다. 그 역할을 맡는 게 디스패처다. 코루틴 표준 라이브러리는 네 종류의 디스패처를 제공한다.

디스패처풀 구성권장 용도
Dispatchers.DefaultCPU 코어 수만큼의 스레드 풀정렬, JSON 파싱, 압축, 이미지 처리 같은 CPU 작업
Dispatchers.IO코어 수와 64 중 큰 값, 필요 시 확장네트워크, DB, 파일 입출력처럼 블로킹 대기가 잦은 작업
Dispatchers.MainUI 프레임워크의 메인 스레드안드로이드, 데스크톱 UI 갱신
Dispatchers.Unconfined고정되지 않은 풀테스트, 디버깅, 호출자 스레드를 그대로 쓰는 특수 케이스

Dispatchers.IO는 블로킹 대기 용도로 설계돼 있어서, 압축이나 정렬 같은 CPU 작업을 여기로 던지면 풀이 금방 부족해진다. 그런 작업은 Dispatchers.Default로 보낸다. 디스패처는 CoroutineContext의 한 요소다. CoroutineContext는 Job, Dispatcher, CoroutineName, CoroutineExceptionHandler 같은 요소를 + 연산자로 합쳐 만든 일종의 작은 맵이고, CoroutineScope는 이 컨텍스트를 들고 다니면서 launch, async 같은 빌더 호출 시 자동으로 자식 코루틴의 부모 컨텍스트를 지정한다. 새로 launch하면 자식 Job이 부모 Job의 자식으로 등록되고, 디스패처는 별도 지정이 없으면 부모를 상속한다.

class OrderProcessor {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineName("Order"))
 
    fun startBackgroundWork() {
        scope.launch { warmupCache() }
    }
 
    fun shutdown() {
        scope.cancel()
    }
}

직접 만든 CoroutineScope는 명시적으로 cancel을 호출해야 누수가 없다. GlobalScope.launch는 가능한 한 피한다. 어떤 상위 Job에도 속하지 않아 취소가 어렵고 누수 추적도 어렵기 때문이다. 위 예시처럼 스코프에 Job을 묶어두면 shutdown 한 번으로 자식 코루틴 전체를 정리할 수 있다. 반대로 suspend 함수 안에서 coroutineScope 빌더로 만든 스코프는 블록이 끝날 때 자동으로 모든 자식을 join하므로 별도 cancel 없이도 누수가 생기지 않는다.

withContext는 디스패처 전환에 자주 등장하는 빌더다. suspend 함수 안에서 일시적으로 다른 디스패처로 작업을 옮길 때 쓴다.

suspend fun renderAvatar(userId: Long) {
    val raw = withContext(Dispatchers.IO) { httpClient.fetchAvatarBytes(userId) }
    val resized = withContext(Dispatchers.Default) { imageProcessor.resize(raw) }
    withContext(Dispatchers.Main) { avatarView.setImage(resized) }
}

withContext는 블록이 끝나면 호출자 컨텍스트로 자동 복귀한다. 진입과 탈출 시점에 짧은 디스패치 비용이 들기 때문에, 짧은 코드를 자잘하게 감싸면 오히려 손해다. 자바 라이브러리의 블로킹 메서드를 그대로 부르지 말고 이렇게 withContext(Dispatchers.IO)로 감싸 의도를 드러내면, 호출자가 이 호출이 블로킹임을 알고 디스패처 선택 실수를 줄일 수 있다.

구조화된 동시성과 Job 트리

코루틴의 또 다른 핵심은 구조화된 동시성(Structured Concurrency)이다. 부모 코루틴이 자식들을 트리로 묶고, 부모는 모든 자식이 끝날 때까지 끝나지 못하며, 자식 중 하나가 예외로 죽으면 형제와 부모가 모두 같이 취소된다.

fun main() = runBlocking {
    val job = launch {
        repeat(5) { i ->
            log.info("step {} active={}", i, isActive)
            delay(500)
        }
    }
 
    delay(1200)
    job.cancel()
    job.join()
    log.info("cancelled={}", job.isCancelled)
}

cancel은 협력적 취소(Cooperative Cancellation)다. 즉시 죽이는 게 아니라 isActive 플래그를 false로 바꾸고 다음 suspend 지점에서 CancellationException을 던진다. 따라서 무한 루프 안에서 suspend 함수를 한 번도 호출하지 않으면 cancel 신호가 전달되지 않는다. 직접 yield를 부르거나 ensureActive로 확인 지점을 만들어줘야 한다.

launch {
    while (true) {
        ensureActive()
        crunchOneChunk()
    }
}

Job 트리는 예외 전파에도 영향을 준다. 일반 Job은 자식 예외가 부모로 전파돼 형제까지 죽인다. SupervisorJob은 자식 사이의 예외 격리를 만든다.

val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
 
scope.launch { riskyJobA() }
scope.launch { riskyJobB() }

riskyJobA가 예외를 던져도 riskyJobB는 계속 실행된다. 일반 Job으로 같은 코드를 짜면 riskyJobB도 함께 취소된다. 안드로이드 viewModelScope, 백엔드의 요청 단위 스코프처럼 자식 작업이 서로 독립적이어야 할 때 SupervisorJob을 사용한다.

Job의 상태는 Active, Cancelling, Cancelled, Completing, Completed의 5단계로 전이한다. cancel 호출 시 Cancelling으로 들어가 자식들의 종료를 기다리고, 모두 끝나면 Cancelled로 확정된다. 이 때문에 cancel 직후 isCancelled가 곧바로 true가 되지 않을 수 있으며, 확정 시점까지 기다리려면 join을 같이 부른다.

Deferred는 Job을 상속한 인터페이스다. async 빌더가 반환하며, await로 결과를 수령한다.

suspend fun loadDashboard(): Dashboard = coroutineScope {
    val userDef = async { userRepository.findCurrent() }
    val ordersDef = async { orderRepository.findRecent() }
    val noticesDef = async { noticeRepository.findActive() }
 
    Dashboard(userDef.await(), ordersDef.await(), noticesDef.await())
}
 
suspend fun loadDashboardWrong(): Dashboard = coroutineScope {
    val user = async { userRepository.findCurrent() }.await()
    val orders = async { orderRepository.findRecent() }.await()
    val notices = async { noticeRepository.findActive() }.await()
    Dashboard(user, orders, notices)
}

두 함수는 결과는 같지만 동작이 다르다. 위 함수는 세 호출이 병렬로 시작된 뒤 await에서 합류하므로 가장 느린 호출의 시간만큼만 걸린다. 아래 함수는 async 직후 곧바로 await을 부르므로 사실상 순차 실행이다. async는 즉시 시작이 기본이고, 진짜 병렬을 원하면 await 호출을 모아서 뒷줄에 둬야 한다. async의 예외는 await 시점까지 미뤄진다는 점도 함께 기억해야 한다. 예외를 더 일찍 감지하고 싶다면 supervisorScope 안에서 launch로 감싸 개별 처리한다.

적용 사례

앞서 다룬 CPS 변환, 디스패처 선택, withContext 전환, 구조화된 동시성, SupervisorJob, async와 await의 병렬 합류는 실제 서버 애플리케이션에서 따로 쓰이지 않고 한 코드 안에서 겹쳐 쓰인다. 세 가지 사례로 그 조합을 확인한다.

사례 1. 외부 서비스 병렬 호출과 요청 스코프

상품 상세 화면은 가격, 리뷰 요약, 재고를 각각 다른 서비스에서 받아와 하나로 합친다. 세 호출을 순서대로 기다리면 응답 시간이 그대로 더해지므로 병렬로 시작하고 합류 지점에서만 기다린다.

class ProductDetailFacade(
    private val priceClient: PriceClient,
    private val reviewClient: ReviewClient,
    private val stockClient: StockClient,
) {
    suspend fun getDetail(productId: Long): ProductDetail = coroutineScope {
        val priceDeferred = async { priceClient.fetchPrice(productId) }
        val reviewDeferred = async { reviewClient.fetchSummary(productId) }
        val stockDeferred = async { stockClient.fetchStock(productId) }
 
        ProductDetail(
            price = priceDeferred.await(),
            reviewSummary = reviewDeferred.await(),
            stock = stockDeferred.await(),
        )
    }
}

getDetail은 자체 CoroutineScope를 만들지 않고 suspend 함수 안에서 coroutineScope 빌더만 쓴다. 이 블록이 그대로 하나의 부모 Job이 되어 세 자식 async를 묶으므로, 함수가 반환되는 시점에는 세 호출이 모두 끝나 있거나 모두 취소돼 있다. 셋 중 하나가 예외를 던지면 coroutineScope가 나머지 두 자식을 즉시 취소하고 예외를 호출자에게 그대로 전파한다. 별도의 취소 로직을 작성하지 않아도 구조화된 동시성이 이 정리를 대신한다.

병렬 조회 자체는 옳아도, 그 결과를 캐시에 미리 데워두는 부가 작업까지 같은 방식으로 처리하면 문제가 생긴다.

// 잘못된 패턴: GlobalScope로 캐시 예열을 흘려보내 요청 생명주기 밖에 방치한다
class CacheWarmerWrong(private val priceClient: PriceClient) {
    fun warmupPriceCache(productId: Long) {
        GlobalScope.launch {
            priceClient.fetchPrice(productId)
        }
    }
}
 
// 올바른 패턴: 컴포넌트가 주입받은 스코프에 묶어 생명주기를 명시적으로 관리한다
class CacheWarmerCorrect(
    private val priceClient: PriceClient,
    private val scope: CoroutineScope,
) {
    fun warmupPriceCache(productId: Long) {
        scope.launch {
            priceClient.fetchPrice(productId)
        }
    }
}

GlobalScope.launch로 시작한 코루틴은 어떤 부모 Job에도 속하지 않아 애플리케이션이 내려갈 때까지 살아 있는다. 테스트에서 컨텍스트를 반복해 올리고 내리면 이전 실행에서 시작한 캐시 예열 코루틴이 계속 남아 다음 테스트의 상태를 오염시킨다. 컴포넌트가 자기 소유의 CoroutineScope를 주입받아 launch하면 그 스코프의 Job이 부모가 되고, 스코프를 cancel하는 한 번의 호출로 안에 떠 있는 모든 코루틴이 함께 정리된다.

사례 2. 블로킹 호출의 디스패처 격리

레거시 모듈의 JDBC(Java Database Connectivity) 기반 JdbcTemplate은 커넥션을 얻고 응답을 받을 때까지 호출 스레드를 그대로 블로킹한다. 이 호출을 어떤 디스패처 위에서 부르느냐에 따라 CPU 작업 전체가 지연될 수 있다.

class LegacyOrderRepository(private val jdbcTemplate: JdbcTemplate) {
    fun findById(orderId: Long): OrderRow =
        jdbcTemplate.queryForObject(SELECT_ORDER_SQL, arrayOf(orderId)) { rs, _ -> OrderRow.from(rs) }
}
 
// 잘못된 패턴: 블로킹 호출이 CPU 작업용 디스패처의 스레드를 그대로 점유한다
suspend fun loadOrderWrong(repository: LegacyOrderRepository, orderId: Long): OrderRow =
    withContext(Dispatchers.Default) {
        repository.findById(orderId)
    }
 
// 올바른 패턴: 블로킹 호출은 대기 전용으로 설계된 디스패처로 격리한다
suspend fun loadOrderCorrect(repository: LegacyOrderRepository, orderId: Long): OrderRow =
    withContext(Dispatchers.IO) {
        repository.findById(orderId)
    }

Dispatchers.Default는 CPU 코어 수만큼만 스레드를 두고 정렬이나 JSON 파싱 같은 계산 작업을 위해 설계됐다. 블로킹 JDBC 호출 몇 개가 이 풀의 스레드를 점유하면 코어 수만큼만 있는 스레드가 대기 상태로 묶여 진짜 계산 작업이 밀린다. Dispatchers.IO는 애초에 대기가 잦은 작업을 전제로 스레드 수를 넉넉히 잡아두므로(코어 수와 64 중 큰 값), 블로킹 구간을 여기로 옮기면 계산용 풀은 계산 작업에만 전념한다.

사례 3. 백그라운드 작업 스코프와 애플리케이션 종료

일 단위 집계처럼 요청과 무관하게 도는 백그라운드 작업은 애플리케이션 생명주기에 맞춰 스코프를 정리해야 재기동 때마다 좀비 코루틴이 남지 않는다.

class ReportingWorker {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineName("Reporting"))
 
    fun scheduleDailyAggregation() {
        scope.launch { aggregateYesterdaySales() }
        scope.launch { aggregateYesterdayRefunds() }
    }
 
    fun shutdown() {
        scope.cancel()
    }
}

두 launch가 SupervisorJob 아래 형제로 묶여 있어서, 환불 집계가 예외로 죽어도 매출 집계는 영향을 받지 않는다. 구조화된 동시성과 Job 트리에서 다룬 예외 격리가 여기서는 서로 무관한 두 배치 작업이 하나가 실패해도 나머지를 끝까지 진행시키는 형태로 나타난다. shutdown에서 scope.cancel()을 호출하면 애플리케이션이 내려가는 시점에 진행 중이던 집계 코루틴이 함께 정리된다. 이 호출을 애플리케이션 종료 훅에 연결해두지 않으면, 재기동할 때마다 이전 인스턴스의 코루틴이 아무도 참조하지 않는 상태로 계속 실행되며 리소스를 점유한다.

사례조합한 개념선택 이유
외부 서비스 병렬 호출coroutineScope, async, await응답 시간을 합이 아니라 최대값으로 줄이고, 실패 시 형제 호출을 자동으로 취소
블로킹 호출 격리withContext, Dispatchers.IO대기가 잦은 호출을 계산용 스레드 풀에서 분리
백그라운드 작업 생명주기SupervisorJob, scope.cancel형제 작업 간 예외 격리 + 애플리케이션 종료와 정리 시점을 명시적으로 연결